Appearance
定制篇-自定义 Electron 原生应用菜单
前言
本章节主要实现自定义原生应用菜单,如果你对本章节内容兴趣不大,可以快速阅读或跳过。
默认行为
我们在打开应用程序时,通常在左上角看到一些菜单项,以 VSCode
为例,下面是它的菜单项
再看看我们的简历平台,也存在一系列的菜单项
可能有小伙伴就会问了,我都没写过这玩意,这哪来的呢?实际上,如果应用没有设置菜单的话,系统会生成一个默认菜单。 默认生成的菜单中包含了一些初始选项,例如 文件
,编辑
, 视图
,窗口
,帮助
。你可以参考这个 electron-default-menu
默认菜单项肯定是难以满足我们的需求,我们总会有一些奇奇怪怪的想法,接下来,让我们自定义菜单行为,实现定制化的功能。
通过例子看实现
小伙伴们可以先去官方文档查看一下Menu、MenuItem相关的信息。接下来通过一个简单的例子,帮助大家了解一下 Elector 中的菜单栏~
我们在 app/main
文件夹下,新增customMenu.ts
文件,顾名思义,这是我们自定义的菜单
ts
// app/main/customMenu.ts
import { dialog, MenuItemConstructorOptions, MenuItem } from 'electron';
const customMenu: (MenuItemConstructorOptions | MenuItem)[] = [
{
label: '我是简历平台自定义菜单栏',
role: 'help',
submenu: [
{
label: '关于',
click: function () {
dialog.showMessageBox({
type: 'question',
title: '提问环节',
message: '谁最帅 ?',
detail: '彭于晏广州分晏,不接受反驳',
});
},
},
],
},
{
label: '自定义的编辑菜单栏',
submenu: [
{
label: '复制',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
},
{
label: '粘贴',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
},
],
},
];
export default customMenu;
接着在主进程中引入 customMenu
文件
ts
// app/main/electron.ts
import { app, Menu } from 'electron';
import customMenu from './customMenu';
app.on('ready', () => {
// ...
const menu = Menu.buildFromTemplate(customMenu);
Menu.setApplicationMenu(menu);
});
重新运行一下 npm run start:main
,然后看看左上角的菜单栏是否发生了改变。
与我们预期一致,让我们看看自定义菜单栏是如何实现的。
首先,我们的 Menu 菜单栏是在主进程中执行,Menu 提供了一个静态方法 buildFromTemplate
,通过该方法,我们可以构建菜单栏。该方法的入参是一个 MenuItemConstructorOptions
类型的数组,用于构建 MenuItem
。
每一条 MenuItem
的属性有很多,这边就不一一列举,感兴趣的小伙伴可点击这里进行详细查看。
以上面例子的代码进行讲解,我们主要看几个属性:
- label 标签名
- submenu 子菜单
- role 菜单项的角色,点击这里看具体值
- accelerator 快捷键事件
- click 点击事件
下面以其中一个菜单项进行讲解,关键在注释!!!
ts
const customMenu: (MenuItemConstructorOptions | MenuItem)[] = [
{
// 1. 一级菜单栏标签名称
label: '我是简历平台自定义菜单栏',
role: 'help',
// 2. 该菜单栏下存在子菜单
submenu: [
{
// 2.1 二级菜单栏标签名称
label: '关于',
// 2.2 该菜单的点击事件
click: function () {
dialog.showMessageBox({
type: 'question',
title: '提问环节',
message: '谁最帅 ?',
detail: '彭于晏广州分晏,不接受反驳',
});
},
// 2.3 快捷键事件(我这边并为为此菜单注册快捷键事件)
// accelerator: '',
},
],
},
];
了解菜单栏的组成属性之后,编写自定义菜单不再是难事!
快捷键事件
也许有小伙伴发懵了,怎么讲菜单栏,一下子就跳到快捷键了?其实上面对于菜单栏的相关基础内容已经讲解完毕,不过既然说到了快捷键事件,那就顺道过一下相关内容吧~
Electron 可通过 globalShortcut 模块进行快捷键事件的自定义。比如常用的复制功能,不会真有人傻乎乎的选中一段文本,再鼠标右键,找到复制选项,进行文本复制吧?Ctrl + C
不香吗?
当然了,系统默认给我们注册了一些快捷键事件,不过默认的快捷键事件肯定是难以满足我们的需求,我们总会有一些奇奇怪怪的想法,下面给个小例子,看看如何自定义快捷键事件
ts
import { app, globalShortcut } from 'electron';
app.whenReady().then(() => {
// 注册一个快捷键
const customCut = globalShortcut.register('CommandOrControl+T', () => {
console.log('牛逼Plus');
});
if (!customCut) {
console.log('凉了,注册失败');
}
// 检测该快捷键是否被注册
console.log(globalShortcut.isRegistered('CommandOrControl+T'));
});
app.on('will-quit', () => {
// 注销快捷键事件
globalShortcut.unregister('CommandOrControl+T');
});
这里的
CommandOrControl
是因为在 Window 和 Mac 上存在一些差异。
重新跑一下 npm run start:main
,此时我们在主应用窗口中,摁下 command+T
(mac) 或者 control+T
(window),然后在主进程窗口中,看看终端是否会输出 牛逼Plus
呢?
留个作业,我想通过快捷键的方式显示应用设置窗口,代码该如何写呢?动动你的小奶袋瓜
自定义简历菜单栏
虽然上面我们实现了简单的自定义菜单栏,但实际上,这会把默认的菜单项给舍弃,以上面的 demo 为示例,你会发现,在应用中无法复制,无法粘贴,这是为什么呢?原因在于我们的菜单栏没有了这些默认菜单事件。我们肯定不会干这么愚蠢的事。
官方提到,如果应用没有设置菜单的话,系统会生成一个默认菜单。 默认生成的菜单中包含了一些初始选项,我们先把这些初始选项拷贝出来,你可以参考这个 electron-default-menu
修改 app/main/customMenu.ts
文件
ts
import _ from 'lodash';
import { MyBrowserWindow } from './electron';
import {
MenuItemConstructorOptions,
shell,
app,
MenuItem,
BrowserWindow,
} from 'electron';
const customMenu: (MenuItemConstructorOptions | MenuItem)[] = [
{
label: '平台',
role: 'help',
submenu: [
{
label: '源码',
click: function () {
shell.openExternal('https://github.com/PDKSophia/visResumeMook');
},
},
{
label: '小册',
click: function () {
shell.openExternal('https://juejin.cn/book/6950646725295996940');
},
},
],
},
{
label: '编辑',
submenu: [
{
label: '撤销',
accelerator: 'CmdOrCtrl+Z',
role: 'undo',
},
{
label: '重做',
accelerator: 'Shift+CmdOrCtrl+Z',
role: 'redo',
},
{
type: 'separator',
},
{
label: '剪切',
accelerator: 'CmdOrCtrl+X',
role: 'cut',
},
{
label: '复制',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
},
{
label: '粘贴',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
},
{
label: '全选',
accelerator: 'CmdOrCtrl+A',
role: 'selectAll',
},
],
},
{
label: '视图',
submenu: [
{
label: '刷新当前页面',
accelerator: 'CmdOrCtrl+R',
click: (item, focusedWindow) => {
if (focusedWindow) {
focusedWindow.reload();
}
},
},
{
label: '切换全屏幕',
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Ctrl+Command+F';
} else {
return 'F11';
}
})(),
click: (item, focusedWindow) => {
if (focusedWindow) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
}
},
},
{
label: '切换开发者工具',
role: 'toggleDevTools',
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Alt+Command+I';
} else {
return 'Ctrl+Shift+I';
}
})(),
click: (item, focusedWindow) => {
if (focusedWindow) {
focusedWindow.webContents.openDevTools();
}
},
},
],
},
{
label: '窗口',
role: 'window',
submenu: [
{
label: '最小化',
accelerator: 'CmdOrCtrl+M',
role: 'minimize',
},
{
label: '关闭',
accelerator: 'CmdOrCtrl+W',
role: 'close',
},
{
type: 'separator',
},
],
},
{
label: '设置',
submenu: [
{
label: '修改简历数据储存路径',
click: () => {
console.log('111');
},
},
],
},
];
if (process.platform === 'darwin') {
const { name } = app;
customMenu.unshift({
label: name,
submenu: [
{
label: '关于 ' + name,
role: 'about',
},
{
type: 'separator',
},
{
label: '服务',
role: 'services',
submenu: [],
},
{
type: 'separator',
},
{
label: 'Hide ' + name,
accelerator: 'Command+H',
role: 'hide',
},
{
label: 'Hide Others',
accelerator: 'Command+Shift+H',
role: 'hideOthers',
},
{
label: 'Show All',
role: 'unhide',
},
{
type: 'separator',
},
{
label: '退出',
accelerator: 'Command+Q',
click: function () {
app.quit();
},
},
],
});
}
export default customMenu;
此时我们重新运行 npm run start:main
,可以发现,左上角的菜单栏展示与我们的预期一致。我们点击 平台介绍
菜单下的小册,会发现跳转到了小册介绍页;链接的调整固然无误,但接下来的问题是,如何在点击设置
时,显示我们的应用设置窗口。
细心的小伙伴应该发现,在第 17 章时,我们是新增了一个应用窗口,这就使得我们每次启动应用,该应用设置窗口就会创建并显示。
我们所期望的是:初始化创建应用设置窗口,但不显示(窗口隐藏),在点击菜单设置
时,再显示应用设置窗口,点击关闭则将窗口隐藏。接下来让我们一步步实现(伪代码,看注释!!!)
- 初始化创建应用设置窗口,对该窗口隐藏
ts
export interface MyBrowserWindow extends BrowserWindow {
uid?: string;
}
function createWindow() {
// 创建应用设置窗口
const settingWindow: MyBrowserWindow = new BrowserWindow({
width: 720,
height: 240,
show: false, // 设置为 false,使得窗口创建时不展示
resizable: false,
webPreferences: {
devTools: true,
nodeIntegration: true,
},
});
settingWindow.uid = 'settingWindow'; // 添加自己唯一的窗口属性
}
- 在点击菜单
设置
时,显示应用设置窗口
修改 customMenu.ts
文件,在回调函数中,得到所有窗口实例,通过 uid
得到具体窗口,进行展示
ts
import _ from 'lodash';
import { MyBrowserWindow } from './electron';
import { MenuItemConstructorOptions, shell, MenuItem, BrowserWindow } from 'electron';
// 伪代码
{
label: '设置',
submenu: [
{
label: '修改简历数据储存路径',
click: () => {
const wins: MyBrowserWindow[] = BrowserWindow.getAllWindows();
const currentWindow = _.find(wins, (w) => w.uid === 'settingWindow');
if (currentWindow) {
currentWindow.show(); // 显示窗口
}
},
},
],
}
- 点击关闭,将应用设置窗口隐藏
由于我们点击窗口的 x
号进行关闭,会将该窗口销毁,而实际上,我们期望的是将该窗口进行隐藏,所有需要重写一下 close
事件
ts
// 自定义settingWindow的关闭事件
settingWindow.on('close', async (e) => {
settingWindow.hide(); // 隐藏窗口
e.preventDefault();
e.returnValue = false;
});
小伙伴们一定要记住,修改了主进程的代码,需要重新执行 npm run start:main
,这时候就能看到最终的菜单栏效果了。
代码可访问:完成自定义菜单栏 commit
坑
也许小伙伴们觉得好像没啥毛病,但实际上,我们通过快捷键 command+Q
或者手动退出,就会发现,好像程序没有关掉?为什么呢?我猜测,当我们点击退出时,实际上会对所有的窗口都实现退出效果,但由于我们对 settingWindow
的退出重写,导致我们主应用窗口退出了,而应用设置窗口还残留着,并且我们将其隐藏掉,导致应用无法完全退出的尴尬局面。那该如何处理呢?
只能通过迂回的方式实现。BrowserWindow
有个配置,可以隐藏掉原生的菜单栏,我们禁掉原生菜单栏,手动实现。然后通过 IPC 的方式实现窗口的显示、隐藏。
我们先前往 renderer/windowPages/setting
,修改一下 index.tsx 的逻辑代码,给它手动实现一个菜单栏,下面是伪代码,部分代码省略
ts
// renderer/windowPages/setting/index.tsx
function Setting() {
const onHideWindow = () => {
ipcRenderer.send('Electron:SettingWindow-hide-event');
};
const onMinWindow = () => {
ipcRenderer.send('Electron:SettingWindow-min-event');
};
return (
<div styleName="container">
<div styleName="menu">
<div styleName="hide" onClick={onHideWindow}>x</div>
<div styleName="min" onClick={onMinWindow}>-</div>
</div>
</div>
);
}
export default Setting;
接下来需要在主进程添加 IPC 通信的事件处理,顺道把拦截的 onclose
代码段删除,同时为应用设置窗口添加 frame
属性
ts
// app/main/electron.ts
function createWindow() {
const settingWindow: MyBrowserWindow = new BrowserWindow({
width: 720,
height: 240,
resizable: false,
// 👇 第一步. 添加该属性
show: false,
frame: false,
webPreferences: {
devTools: true,
nodeIntegration: true,
},
});
settingWindow.uid = 'settingWindow';
// 👇 第二步.删除掉自定义的关闭事件
// settingWindow.on('close', async (e) => {
// settingWindow.hide();
// e.preventDefault();
// e.returnValue = false;
// });
// 👇 第三步. 新增IPC事件监听
ipcMain.on('Electron:SettingWindow-hide-event', () => {
// https://www.electronjs.org/docs/api/browser-window#winisvisible
if (settingWindow.isVisible()) {
settingWindow.hide();
}
});
ipcMain.on('Electron:SettingWindow-min-event', () => {
// https://www.electronjs.org/docs/api/browser-window#winisminimized
if (settingWindow.isVisible()) {
settingWindow.minimize();
}
});
}
不要忘记了在自定义的菜单栏中,也需要对应做一下修改,前往 app/main/customMenu.ts
ts
// app/main/customMenu.ts
{
label: '设置',
submenu: [
{
label: '修改简历数据储存路径',
click: () => {
const wins: MyBrowserWindow[] = BrowserWindow.getAllWindows();
const currentWindow = _.find(wins, (w) => w.uid === 'settingWindow');
if (currentWindow) {
if (!currentWindow.isVisible()) {
currentWindow.show();
}
if (currentWindow.isMinimized()) {
currentWindow.restore();
}
}
},
},
],
}
代码可访问:实现无边框窗口,自定义菜单栏功能 commit
最后
本章节主要实现自定义原生应用菜单,这是一个特别重要的模块,通过文档的介绍,简单例子的展示,快捷键事件的额外讲解,最后通过真实的场景,带领大家实现自定义原生菜单。
从一开始的实现,到自定义 onclose
事件会带来的问题,再到实现无边框菜单的窗口,最后通过 IPC 通信的方式,实现窗口的隐藏、最小化等功能。希望本章节后,小伙伴们能基于此菜单栏,去实现更多复杂而又有趣的定制化菜单。